import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import { getListing, getProperty, nearbyListings, neighbourhoodScore, recentTransactions } from "@/lib/queries"; import { estimateForListing, estimateManual } from "@/lib/estimate"; import { dbAvailable } from "@/lib/db"; import { fmtPrice, fmtArea, fmtDate, fmtInt, daysSince } from "@/lib/format"; import { CATEGORY_LABEL } from "@/lib/normalize"; import { Gallery } from "@/components/gallery"; import { ListingCardView } from "@/components/listing-card"; import { VerdictPill } from "@/components/verdict-pill"; import { Notice } from "@/components/ui"; import { IconExternal, IconBed, IconBath, IconArea, IconPin } from "@/components/icons"; import { ListingTabs } from "./listing-tabs"; import { ListingActions } from "./listing-actions"; import { ContactPanel } from "@/components/contact-panel"; import { ReportButton } from "@/components/report-button"; import { insights, marketPosition, extractFinancials, rentalBudget } from "@/lib/insights"; import { DIRECT_PREFIX, getDirectBySlug, publicAddress } from "@/lib/listings/public"; import { incrementViews } from "@/lib/listings/service"; import { mediaUrl } from "@/lib/listings/media"; import { PROPERTY_TYPE_LABEL } from "@/lib/listings/types"; export const dynamic = "force-dynamic"; type Props = { params: Promise<{ uid: string }> }; const parseJson = (s: string | null | undefined, fb: T): T => { try { const v = JSON.parse(s ?? ""); return (v ?? fb) as T; } catch { return fb; } }; export async function generateMetadata({ params }: Props): Promise { const { uid: raw } = await params; const uid = decodeURIComponent(raw); if (uid.startsWith(DIRECT_PREFIX)) { const d = await getDirectBySlug(uid.slice(DIRECT_PREFIX.length)).catch(() => null); if (!d || d.l.status !== "PUBLISHED") return { title: "Annonce introuvable" }; const rent = d.l.listingType === "RENT"; const title = `${d.l.title ?? PROPERTY_TYPE_LABEL[d.p.propertyType]}, ${d.p.city} — ${fmtPrice(d.card.price, { rent })}`; return { title, description: (d.l.description ?? "").slice(0, 160), openGraph: { title, images: d.media.slice(0, 1).map((m) => `https://www.toveli.ca${mediaUrl(m.storageKey, 1600)}`) }, alternates: { canonical: `/annonce/${encodeURIComponent(uid)}` } }; } if (!dbAvailable()) return {}; const l = getListing(uid); if (!l) return { title: "Annonce introuvable" }; const rent = l.market === "rent"; const title = `${l.address ?? CATEGORY_LABEL[l.category]}, ${l.city} — ${fmtPrice(l.price, { rent })}`; return { title, description: (l.description ?? "").slice(0, 160) || `${CATEGORY_LABEL[l.category]} ${rent ? "à louer" : "à vendre"} à ${l.city}.`, openGraph: { title, images: parseJson(l.images, []).slice(0, 1) }, alternates: { canonical: `/annonce/${encodeURIComponent(l.uid)}` } }; } function Crumbs({ rent, region, city }: { rent: boolean; region: string | null; city: string | null }) { const m = rent ? "location" : "achat"; return ; } function Chips({ typeLabel, bedrooms, bathrooms, powder, area, lot, year, availability, rent, extra = [] }: { typeLabel: string; bedrooms: number | null; bathrooms: number | null; powder?: number | null; area: number | null; lot: number | null; year: number | null; availability?: string | null; rent: boolean; extra?: string[] }) { return
  • {typeLabel}
  • {bedrooms != null &&
  • {fmtInt(bedrooms)} chambre{bedrooms > 1 ? "s" : ""}
  • } {bathrooms != null &&
  • {fmtInt(bathrooms)} salle{bathrooms > 1 ? "s" : ""} de bain{powder ? ` + ${powder} s. d'eau` : ""}
  • } {area != null && area > 0 &&
  • {fmtArea(area)} ({fmtArea(area, "m2")})
  • } {lot != null && lot > 0 &&
  • Terrain {fmtArea(lot)}
  • } {year != null &&
  • Construite en {year}
  • } {availability && rent &&
  • Disponible : {availability === "now" ? "immédiatement" : availability}
  • } {extra.map((e) =>
  • {e}
  • )}
; } /* ───────────── Annonce directe Toveli ───────────── */ async function DirectPage({ slug }: { slug: string }) { const d = await getDirectBySlug(slug).catch(() => null); if (!d || d.l.status !== "PUBLISHED") notFound(); await incrementViews([d.l.id]).catch(() => {}); const rent = d.l.listingType === "RENT"; const c = d.card; const p = d.p; const l = d.l; const images = d.media.map((m) => mediaUrl(m.storageKey, 1600)); const features = (l.inclusions as string[]) ?? []; const details: Record = { ...(l.features as Record), ...(l.parking ? { Stationnement: l.parking } : {}), ...(l.furnished != null ? { Meublé: l.furnished ? "Oui" : "Non" } : {}), ...(l.petsPolicy ? { Animaux: { allowed: "Acceptés", not_allowed: "Refusés", ask: "À discuter" }[l.petsPolicy] } : {}), ...(l.smokingPolicy ? { Fumeur: l.smokingPolicy === "allowed" ? "Permis" : "Non-fumeur" } : {}), ...(l.leaseTerms ? { Bail: l.leaseTerms } : {}) }; const live = !rent && p.lat != null && p.lng != null && !["terrain", "commercial"].includes(p.propertyType) ? estimateManual({ lat: p.lat, lng: p.lng, category: c.category, floorAreaSqft: p.areaSqft, yearBuilt: p.yearBuilt, landAreaSqft: p.lotSqft, label: c.address ?? "Propriété", city: p.city }) : null; const score = p.lat != null && p.lng != null ? neighbourhoodScore(p.lat, p.lng) : null; const nearby = p.lat != null && p.lng != null ? nearbyListings(p.lat, p.lng, c.market, "", 6) : []; const sales = !rent && p.lat != null && p.lng != null ? recentTransactions(p.lat, p.lng, 8) : []; const pos = marketPosition({ market: c.market, category: c.category, city: p.city, region: p.region, price: c.price, area_sqft: p.areaSqft, first_seen: c.firstSeen }); const ins = insights({ market: c.market, category: c.category, city: p.city, price: c.price, area_sqft: p.areaSqft, lot_sqft: p.lotSqft, year_built: p.yearBuilt, bedrooms: c.bedrooms, first_seen: c.firstSeen, image_count: images.length, type_label: c.typeLabel }, live ? { estimate: live.result.estimate, ratio: c.price ? live.result.estimate / c.price : null, verdict: null, confidence: live.result.confidenceLevel } : null, pos, 0, null, score); const budget = rent ? rentalBudget({ price: c.price, area_sqft: p.areaSqft, features, details }) : null; const publisher = l.sourceType === "DIRECT_AGENT" ? "Publiée par un représentant" : "Publiée par le propriétaire"; const showName = (d.owner?.prefs as { privacy?: { showNameOnListings?: boolean } } | null)?.privacy?.showNameOnListings !== false; const jsonLd = { "@context": "https://schema.org", "@type": rent ? "Apartment" : "SingleFamilyResidence", name: l.title ?? undefined, description: (l.description ?? "").slice(0, 500), address: { "@type": "PostalAddress", streetAddress: publicAddress(p) ?? undefined, addressLocality: p.city ?? undefined, addressRegion: "QC", postalCode: p.privacyLevel === "EXACT" ? p.postalCode ?? undefined : undefined, addressCountry: "CA" }, geo: c.lat != null ? { "@type": "GeoCoordinates", latitude: c.lat, longitude: c.lng } : undefined, numberOfRooms: c.bedrooms ?? undefined, floorSize: p.areaSqft ? { "@type": "QuantitativeValue", value: p.areaSqft, unitCode: "FTK" } : undefined, offers: { "@type": "Offer", price: c.price ?? undefined, priceCurrency: "CAD", availability: "https://schema.org/InStock" }, image: images.slice(0, 3).map((i) => `https://www.toveli.ca${i}`) }; return (